page.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import { useParams, useRouter } from "next/navigation";
  4. import { MessageList } from "@/components/dashboard/MessageList";
  5. import { MessageInput } from "@/components/dashboard/MessageInput";
  6. import { ChatHeader } from "@/components/dashboard/ChatHeader";
  7. import { useAuth } from "@/features/agent/hooks/useAuth";
  8. import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
  9. import {
  10. fetchMessages,
  11. sendMessage as sendMessageApi,
  12. markMessagesRead,
  13. } from "@/features/agent/services/messageApi";
  14. import { fetchConversationDetail } from "@/features/agent/services/conversationApi";
  15. import {
  16. ConversationDetail,
  17. ConversationSummary,
  18. MessageItem,
  19. MessagesReadPayload,
  20. ChatWebSocketPayload,
  21. } from "@/features/agent/types";
  22. import type { WSMessage } from "@/lib/websocket";
  23. export default function AgentChatPage() {
  24. const params = useParams();
  25. const router = useRouter();
  26. const { agent, loading: authLoading } = useAuth();
  27. const conversationId = useMemo(() => {
  28. const id = params?.conversationId;
  29. if (!id) {
  30. return null;
  31. }
  32. const parsed = Number.parseInt(String(id), 10);
  33. return Number.isNaN(parsed) ? null : parsed;
  34. }, [params]);
  35. const [messages, setMessages] = useState<MessageItem[]>([]);
  36. const [conversationDetail, setConversationDetail] =
  37. useState<ConversationDetail | null>(null);
  38. const [conversationSummary, setConversationSummary] =
  39. useState<ConversationSummary | null>(null);
  40. const [messageInput, setMessageInput] = useState("");
  41. const [loadingMessages, setLoadingMessages] = useState(true);
  42. const [sending, setSending] = useState(false);
  43. const [highlightKeyword, setHighlightKeyword] = useState("");
  44. const handleMarkMessagesRead = useCallback(
  45. async (
  46. conversationIdParam?: number,
  47. readerIsAgentParam?: boolean
  48. ) => {
  49. const targetConversationId = conversationIdParam ?? conversationId;
  50. const targetReaderIsAgent = readerIsAgentParam ?? true;
  51. if (!targetConversationId) {
  52. return;
  53. }
  54. const result = await markMessagesRead(
  55. targetConversationId,
  56. targetReaderIsAgent
  57. );
  58. if (!result || result.message_ids.length === 0) {
  59. return;
  60. }
  61. const idSet = new Set(result.message_ids);
  62. setMessages((prev) =>
  63. prev.map((msg) =>
  64. idSet.has(msg.id)
  65. ? {
  66. ...msg,
  67. is_read: true,
  68. read_at: result.read_at ?? msg.read_at ?? null,
  69. }
  70. : msg
  71. )
  72. );
  73. if (targetReaderIsAgent) {
  74. setConversationDetail((prev) =>
  75. prev ? { ...prev, unread_count: result.unread_count } : prev
  76. );
  77. setConversationSummary((prev) =>
  78. prev ? { ...prev, unread_count: result.unread_count } : prev
  79. );
  80. } else {
  81. setConversationDetail((prev) =>
  82. prev
  83. ? {
  84. ...prev,
  85. last_seen_at: result.read_at ?? prev.last_seen_at ?? null,
  86. }
  87. : prev
  88. );
  89. }
  90. },
  91. [conversationId]
  92. );
  93. const loadConversationDetail = useCallback(async () => {
  94. if (!conversationId) {
  95. return;
  96. }
  97. const detail = await fetchConversationDetail(conversationId);
  98. if (detail) {
  99. setConversationDetail(detail);
  100. setConversationSummary({
  101. id: detail.id,
  102. visitor_id: detail.visitor_id,
  103. agent_id: detail.agent_id,
  104. status: detail.status,
  105. created_at: detail.created_at,
  106. updated_at: detail.updated_at,
  107. last_message: detail.last_message,
  108. unread_count: detail.unread_count ?? 0,
  109. });
  110. }
  111. }, [conversationId]);
  112. const loadMessages = useCallback(async () => {
  113. if (!conversationId) {
  114. return;
  115. }
  116. setLoadingMessages(true);
  117. try {
  118. const data = await fetchMessages(conversationId);
  119. setMessages(data);
  120. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  121. } catch (error) {
  122. console.error("拉取消息失败:", error);
  123. } finally {
  124. setLoadingMessages(false);
  125. }
  126. }, [conversationId]);
  127. useEffect(() => {
  128. if (!conversationId) {
  129. router.push("/agent/dashboard");
  130. }
  131. }, [conversationId, router]);
  132. useEffect(() => {
  133. if (!conversationId || !agent) {
  134. return;
  135. }
  136. loadConversationDetail();
  137. loadMessages();
  138. }, [conversationId, agent, loadConversationDetail, loadMessages]);
  139. const handleSendMessage = useCallback(async () => {
  140. if (!conversationId || !agent?.id || !messageInput.trim() || sending) {
  141. return;
  142. }
  143. setSending(true);
  144. try {
  145. await sendMessageApi({
  146. conversationId,
  147. content: messageInput,
  148. senderId: agent.id,
  149. });
  150. setMessageInput("");
  151. } catch (error) {
  152. console.error(error);
  153. alert((error as Error).message);
  154. } finally {
  155. setSending(false);
  156. }
  157. }, [agent?.id, conversationId, messageInput, sending]);
  158. const handleNewMessage = useCallback(
  159. (message: MessageItem) => {
  160. setMessages((prev) => {
  161. const exists = prev.some((item) => item.id === message.id);
  162. if (exists) {
  163. return prev;
  164. }
  165. return [...prev, message];
  166. });
  167. setConversationDetail((prev) => {
  168. if (!prev) {
  169. return prev;
  170. }
  171. const nextUnread =
  172. !message.sender_is_agent && message.message_type !== "system_message"
  173. ? (prev.unread_count ?? 0) + 1
  174. : prev.unread_count ?? 0;
  175. return {
  176. ...prev,
  177. updated_at: message.created_at,
  178. unread_count:
  179. message.sender_is_agent || message.message_type === "system_message"
  180. ? prev.unread_count ?? 0
  181. : nextUnread,
  182. last_message: {
  183. id: message.id,
  184. content: message.content,
  185. sender_is_agent: message.sender_is_agent,
  186. message_type: message.message_type ?? "user_message",
  187. is_read: Boolean(message.is_read),
  188. read_at: message.read_at ?? null,
  189. created_at: message.created_at,
  190. },
  191. };
  192. });
  193. setConversationSummary((prev) => {
  194. if (!prev) {
  195. return prev;
  196. }
  197. const nextUnread =
  198. !message.sender_is_agent && message.message_type !== "system_message"
  199. ? (prev.unread_count ?? 0) + 1
  200. : prev.unread_count ?? 0;
  201. return {
  202. ...prev,
  203. updated_at: message.created_at,
  204. unread_count:
  205. message.sender_is_agent || message.message_type === "system_message"
  206. ? prev.unread_count ?? 0
  207. : nextUnread,
  208. last_message: {
  209. id: message.id,
  210. content: message.content,
  211. sender_is_agent: message.sender_is_agent,
  212. message_type: message.message_type ?? "user_message",
  213. is_read: Boolean(message.is_read),
  214. read_at: message.read_at ?? null,
  215. created_at: message.created_at,
  216. },
  217. };
  218. });
  219. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  220. if (message.conversation_id === conversationId) {
  221. loadConversationDetail();
  222. }
  223. },
  224. [conversationId, loadConversationDetail]
  225. );
  226. const handleMessagesReadEvent = useCallback(
  227. (payload: MessagesReadPayload) => {
  228. if (!conversationId) {
  229. return;
  230. }
  231. // 检查对话ID是否匹配
  232. const payloadConversationId = payload?.conversation_id;
  233. if (payloadConversationId && payloadConversationId !== conversationId) {
  234. return;
  235. }
  236. const ids = Array.isArray(payload?.message_ids)
  237. ? payload.message_ids
  238. : [];
  239. if (ids.length === 0) {
  240. return;
  241. }
  242. const readAt = payload?.read_at;
  243. const readerIsAgent = Boolean(payload?.reader_is_agent);
  244. const unreadCount =
  245. typeof payload?.unread_count === "number"
  246. ? payload.unread_count
  247. : undefined;
  248. // 对于客服端:只有当 reader_is_agent === false 时(访客读取了客服的消息),
  249. // 才更新客服消息(sender_is_agent === true)的已读状态
  250. if (readerIsAgent) {
  251. // 客服读取了访客的消息,更新未读数(不更新消息的已读状态,因为这是客服读取的)
  252. if (unreadCount !== undefined) {
  253. setConversationDetail((prev) =>
  254. prev ? { ...prev, unread_count: unreadCount } : prev
  255. );
  256. setConversationSummary((prev) =>
  257. prev ? { ...prev, unread_count: unreadCount } : prev
  258. );
  259. }
  260. return;
  261. }
  262. // 访客读取了客服的消息,更新客服消息的已读状态
  263. const messageIdSet = new Set(ids);
  264. setMessages((prev) =>
  265. prev.map((msg) =>
  266. // 只更新客服自己的消息(sender_is_agent === true)的已读状态
  267. messageIdSet.has(msg.id) && msg.sender_is_agent
  268. ? {
  269. ...msg,
  270. is_read: true,
  271. read_at: readAt ?? msg.read_at ?? null,
  272. }
  273. : msg
  274. )
  275. );
  276. // 更新访客的最后活跃时间
  277. setConversationDetail((prev) =>
  278. prev
  279. ? { ...prev, last_seen_at: readAt ?? prev.last_seen_at ?? null }
  280. : prev
  281. );
  282. },
  283. [conversationId]
  284. );
  285. const handleWebSocketMessage = useCallback(
  286. (event: WSMessage<ChatWebSocketPayload>) => {
  287. if (!event) {
  288. return;
  289. }
  290. if (event.type === "new_message" && event.data) {
  291. const data = event.data as MessageItem;
  292. if (typeof data.conversation_id === "number") {
  293. handleNewMessage(data);
  294. }
  295. } else if (event.type === "messages_read") {
  296. // 确保处理已读事件,并传入对话ID
  297. const payload = event.data as MessagesReadPayload;
  298. if (!payload.conversation_id && event.conversation_id) {
  299. payload.conversation_id = event.conversation_id;
  300. }
  301. handleMessagesReadEvent(payload);
  302. }
  303. },
  304. [handleMessagesReadEvent, handleNewMessage]
  305. );
  306. useWebSocket<ChatWebSocketPayload>({
  307. conversationId,
  308. enabled: Boolean(conversationId),
  309. onMessage: handleWebSocketMessage,
  310. onError: (error) => console.error("WebSocket 连接错误:", error),
  311. onClose: () => console.log("WebSocket 连接已关闭"),
  312. });
  313. const handleBack = useCallback(() => {
  314. router.push("/agent/dashboard");
  315. }, [router]);
  316. const unreadCount =
  317. conversationDetail?.unread_count ?? conversationSummary?.unread_count ?? 0;
  318. if (authLoading || !agent) {
  319. return (
  320. <div className="flex justify-center items-center min-h-screen bg-gray-50 text-gray-600">
  321. 加载中...
  322. </div>
  323. );
  324. }
  325. if (!conversationId) {
  326. return null;
  327. }
  328. return (
  329. <div className="flex flex-col h-screen bg-gray-50">
  330. <div className="bg-white border-b border-gray-200">
  331. <div className="flex items-center gap-4 px-4 h-16">
  332. <button
  333. onClick={handleBack}
  334. className="px-3 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors"
  335. >
  336. ← 返回
  337. </button>
  338. <div className="flex-1">
  339. <ChatHeader
  340. conversationId={conversationId}
  341. lastSeenAt={conversationDetail?.last_seen_at}
  342. unreadCount={unreadCount}
  343. onMarkAllRead={() => handleMarkMessagesRead(conversationId, true)}
  344. onRefresh={() => {
  345. loadMessages();
  346. loadConversationDetail();
  347. }}
  348. />
  349. </div>
  350. </div>
  351. </div>
  352. <div className="flex-1 flex flex-col bg-white">
  353. <MessageList
  354. messages={messages}
  355. loading={loadingMessages}
  356. highlightKeyword={highlightKeyword}
  357. onHighlightClear={() => setHighlightKeyword("")}
  358. currentUserIsAgent={true}
  359. conversationId={conversationId}
  360. onMarkMessagesRead={handleMarkMessagesRead}
  361. />
  362. <MessageInput
  363. value={messageInput}
  364. onChange={setMessageInput}
  365. onSubmit={handleSendMessage}
  366. sending={sending}
  367. />
  368. </div>
  369. </div>
  370. );
  371. }